'use client'; import './style.scss'; import { use, useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { CreditCard, ExternalLink, Gamepad2, Package, ShoppingCart, Ticket } from 'lucide-react'; import { fetchApi } from '@/lib/utils/client'; import Loading from '@/app/component/Loading'; import PayButton, { type PayButtonState } from '@/app/component/PayButton'; import useAuth from '@/hooks/useAuth'; import useCart from '@/hooks/useCart'; import type { ProductDetail, ProductType, ChannelSearchResponse, ChannelSearchRow } from '@/types/store'; /** 최근 후원 채널 localStorage MRU. 최신 사용 순(newest first), 최대 5개. */ const RECENT_KEY = 'antooza:recent-donation-channels'; const RECENT_MAX = 5; function loadRecent(): ChannelSearchRow[] { if (typeof window === 'undefined') { return []; } try { const raw = localStorage.getItem(RECENT_KEY); return raw ? JSON.parse(raw) as ChannelSearchRow[] : []; } catch { return []; } } function saveRecent(list: ChannelSearchRow[]) { if (typeof window === 'undefined') { return; } localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX))); } function addRecent(ch: ChannelSearchRow) { const cur = loadRecent().filter(c => c.id !== ch.id); saveRecent([ch, ...cur]); } function removeRecent(id: number) { saveRecent(loadRecent().filter(c => c.id !== id)); } const TYPE_BADGE_CLASS: Record = { 1: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300', 2: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300' }; const TYPE_LABEL: Record = { 1: '실물', 2: '쿠폰' }; function TypeBadge({ type, size = 'sm' }: { type: ProductType; size?: 'sm'|'md' }) { const Icon = type === 2 ? Ticket : Package; const padCls = size === 'md' ? 'p-1.5' : 'p-1'; const iconCls = size === 'md' ? 'size-3.5' : 'size-3'; return ( ); } const HARD_MAX = 999; function formatPrice(n: number): string { return n.toLocaleString('ko-KR'); } function clampQuantity(value: number, stock: number, minPurchase: number, maxPurchase: number): number { const stockCap = stock > 0 ? Math.min(stock, HARD_MAX) : HARD_MAX; const upper = Math.min(maxPurchase, stockCap); return Math.max(minPurchase, Math.min(value, upper)); } export default function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const productID = parseInt(id, 10); const router = useRouter(); const searchParams = useSearchParams(); const codeParam = searchParams.get('code'); const { loginCheck } = useAuth(); const { addItem, setChannel } = useCart(); const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [quantity, setQuantity] = useState(1); // 상품 로드 후 초기 수량을 minPurchase로 세팅 useEffect(() => { if (product) { setQuantity(product.minPurchase); setOptedOut(false); // 상품 전환 시 이전 상품의 선택안함 상태 잔존 방지 } }, [product]); const [selectedChannel, setSelectedChannel] = useState(null); const [optedOut, setOptedOut] = useState(false); const [channelModalOpen, setChannelModalOpen] = useState(false); const [pendingPurchase, setPendingPurchase] = useState(false); const [payState] = useState('idle'); const [submitError, setSubmitError] = useState(null); const [tab, setTab] = useState<'product'|'game'>('product'); const load = useCallback(async () => { setLoading(true); const res = await fetchApi(`/api/store/products/${productID}`, { silent: true }); if (res.success && res.data) { setProduct(res.data); setError(null); } else { setError(res.message || '상품을 불러올 수 없습니다.'); } setLoading(false); }, [productID]); useEffect(() => { load(); }, [load]); // querystring ?code=XXX → 후원 채널 자동 설정 (case-insensitive 검증, 무효 시 조용히 무시) useEffect(() => { if (!codeParam) { return; } let cancelled = false; (async () => { const res = await fetchApi(`/api/store/channels/by-code?code=${encodeURIComponent(codeParam)}`, { silent: true }); if (cancelled) { return; } if (res.success && res.data) { setSelectedChannel(res.data); addRecent(res.data); } })(); return () => { cancelled = true; }; }, [codeParam]); const proceedPurchase = (channel: ChannelSearchRow|null) => { if (!product) { return; } // 바로 구매: 현재 선택 상품을 cart 에 add 후 /checkout 으로 이동. // 기존 cart 항목은 보존되며 함께 결제됨. addItem({ productID: product.id, name: product.name, thumbnail: product.thumbnail, price: product.salePrice, type: product.type, gameName: product.gameKorName, stock: product.stock, requireDonationChannel: product.requireDonationChannel, quantity }); setChannel(channel); router.push('/checkout'); }; const handlePurchase = () => { if (!loginCheck()) { return; } if (!product) { return; } if (quantity < 1) { setSubmitError('수량은 1 이상이어야 합니다.'); return; } setSubmitError(null); // 후원 채널 확인 절차 강제 — 채널 미선택 시 (필수 상품이거나 선택안함 미체크면) 모달을 먼저 연다. if (!selectedChannel && (product.requireDonationChannel || !optedOut)) { setPendingPurchase(true); setChannelModalOpen(true); return; } proceedPurchase(selectedChannel); }; const handleAddToCart = () => { if (!product) { return; } addItem({ productID: product.id, name: product.name, thumbnail: product.thumbnail, price: product.salePrice, type: product.type, gameName: product.gameKorName, stock: product.stock, requireDonationChannel: product.requireDonationChannel, quantity }); setSubmitError(null); // 작은 피드백 — 차후 toast 시스템으로 교체 가능 alert('장바구니에 담았습니다.'); }; if (loading) { return ; } if (error || !product) { return (

{error || '상품을 찾을 수 없습니다.'}

상점으로 돌아가기
); } const unitPrice = product.salePrice; // 할인 반영된 단가 const total = unitPrice * quantity; const stockLabel = product.stock === -1 ? '' : `재고 ${product.stock}개`; const hasDiscount = product.discountType !== 0 && product.salePrice !== product.price; return (
{/* breadcrumb — 모두 파랑 계열 */}
{/* 좌측 — 상품 이미지 (작게) */}
{product.thumbnail ? ( // eslint-disable-next-line @next/next/no-img-element {product.name} ) : ( 이미지 없음 )}
{/* 우측 — 주문 영역 */}
{product.gameKorName} {product.gameEngName && ( ({product.gameEngName}) )}

{product.name}

{product.gameLink ? ( {product.gamePublisher} ) : ( product.gamePublisher )}
{formatPrice(unitPrice)}P
{hasDiscount && (
{formatPrice(product.price)}P {product.discountType === 1 ? `-${product.discountValue}%` : `-${formatPrice(product.discountValue)}P`}
)} {product.stock !== -1 && (
{stockLabel}
)}
0 ? product.stock : HARD_MAX)} value={quantity} onChange={(e) => { const v = parseInt(e.target.value || '1', 10); setQuantity(clampQuantity(isNaN(v) ? product.minPurchase : v, product.stock, product.minPurchase, product.maxPurchase)); }} onBlur={(e) => { const v = parseInt(e.target.value || '1', 10); setQuantity(clampQuantity(isNaN(v) ? product.minPurchase : v, product.stock, product.minPurchase, product.maxPurchase)); }} className='w-14 text-center bg-white dark:bg-neutral-900 outline-none border-0 appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none' />
{product.minPurchase > 1 ? `최소 ${product.minPurchase}개 / ` : ''}최대 {product.maxPurchase}개
{selectedChannel && ( )}
상품 금액 {formatPrice(unitPrice)}P × {quantity}
결제 금액 {formatPrice(total)}P
{submitError && (
{submitError}
)}
} disabled={product.stock === 0} onClick={handlePurchase} />

* 결제 시 캐시 잔액에서 차감됩니다. (토큰 사용 불가)

{/* 하단 탭 — 상품설명 / 게임소개 */}
{tab === 'product' && ( product.description ? (
) : (

상품 설명이 없습니다.

) )} {tab === 'game' && (
{product.gameBigImage && (
{/* eslint-disable-next-line @next/next/no-img-element */} {product.gameKorName}
)}

{product.gameKorName} {product.gameEngName && ( ({product.gameEngName}) )}

{product.gamePublisher}
{product.gameDescription ? (
) : (

게임 소개가 없습니다.

)} {product.gameLink && ( 공식 사이트 바로가기 → )}
)}
{/* 같은 게임 다른 상품 */} {product.relatedProducts.length > 0 && (

같은 게임의 다른 상품

{product.relatedProducts.map((r) => (
{r.thumbnail ? ( // eslint-disable-next-line @next/next/no-img-element {r.name} ) : ( 이미지 없음 )}
{r.name}
{r.discountType !== 0 && r.salePrice !== r.price ? formatPrice(r.salePrice) : formatPrice(r.price)}P{r.discountType !== 0 && r.salePrice !== r.price ? {formatPrice(r.price)}P : null}
))}
)} {channelModalOpen && ( { setChannelModalOpen(false); setPendingPurchase(false); }} onSelect={(ch) => { setSelectedChannel(ch); setOptedOut(false); addRecent(ch); setChannelModalOpen(false); if (pendingPurchase) { setPendingPurchase(false); proceedPurchase(ch); } }} onSkip={() => { setSelectedChannel(null); setOptedOut(true); setChannelModalOpen(false); if (pendingPurchase) { setPendingPurchase(false); proceedPurchase(null); } }} /> )}
); } function ChannelSelectModal({ onClose, onSelect, onSkip, requireChannel }: { onClose: () => void; onSelect: (ch: ChannelSearchRow) => void; onSkip: () => void; requireChannel: boolean; }) { const [keyword, setKeyword] = useState(''); const [recent, setRecent] = useState([]); const [randoms, setRandoms] = useState([]); const [searchResults, setSearchResults] = useState([]); const [loading, setLoading] = useState(true); const [initialized, setInitialized] = useState(false); // 모달 mount: localStorage 의 최근 채널을 서버에 검증(stale 자동 제거) 후 표시 useEffect(() => { let cancelled = false; (async () => { const cached = loadRecent(); let validated: ChannelSearchRow[] = []; if (cached.length > 0) { const ids = cached.map(c => c.id).join(','); const res = await fetchApi<{ list: ChannelSearchRow[] }>(`/api/store/channels/by-ids?ids=${ids}`, { silent: true }); if (cancelled) { return; } if (res.success && res.data) { // 서버 응답에 포함된 ID 만 유지하고 캐시 순서(최신순) 보존. 서버 최신 정보로 덮어쓰기. const fresh = new Map(res.data.list.map(c => [c.id, c])); validated = cached.flatMap(c => { const f = fresh.get(c.id); return f ? [f] : []; }); } // 검증 결과로 localStorage 동기화 (stale 제거 + 최신 정보 반영) saveRecent(validated); } if (!cancelled) { setRecent(validated); setInitialized(true); } })(); return () => { cancelled = true; }; }, []); // 검색어 변화에 따른 fetch (debounce 300ms). 검색어 없을 때는 random 으로 부족분 보충. useEffect(() => { if (!initialized) { return; } const handle = setTimeout(async () => { setLoading(true); // 검색어 있을 때: 검색 API 만 호출. recent 무시. if (keyword.trim()) { const params = new URLSearchParams(); params.set('keyword', keyword.trim()); params.set('page', '1'); params.set('perPage', '5'); const res = await fetchApi(`/api/store/channels/search?${params.toString()}`, { silent: true }); if (res.success && res.data) { setSearchResults(res.data.list); } else { setSearchResults([]); } setLoading(false); return; } // 검색어 없을 때: recent 5개면 random 호출 안 함. 부족 시 (RECENT_MAX - recent.length) 만큼 random 으로 보충. const need = RECENT_MAX - recent.length; if (need <= 0) { setRandoms([]); setLoading(false); return; } const params = new URLSearchParams(); params.set('random', 'true'); params.set('page', '1'); // recent 와 중복 가능성 대비해 여유 있게 가져와서 중복 제거 params.set('perPage', String(need + recent.length)); const res = await fetchApi(`/api/store/channels/search?${params.toString()}`, { silent: true }); if (res.success && res.data) { const recentIDs = new Set(recent.map(r => r.id)); const filtered = res.data.list.filter(c => !recentIDs.has(c.id)).slice(0, need); setRandoms(filtered); } else { setRandoms([]); } setLoading(false); }, 300); return () => { clearTimeout(handle); }; }, [keyword, initialized, recent]); const handleRemoveRecent = (id: number) => { removeRecent(id); setRecent(prev => prev.filter(c => c.id !== id)); }; // 표시할 리스트: 검색어 있으면 검색 결과만, 없으면 recent + randoms. const displayList: { row: ChannelSearchRow; isRecent: boolean }[] = keyword.trim() ? searchResults.map(row => ({ row, isRecent: false })) : [ ...recent.map(row => ({ row, isRecent: true })), ...randoms.map(row => ({ row, isRecent: false })) ]; return (
{ e.stopPropagation(); }} >

후원 채널 선택

{ setKeyword(e.target.value); }} placeholder='채널명, 핸들, 후원 코드 검색' className='w-full border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900' maxLength={100} /> {!keyword.trim() ? (

* 최근 후원 채널 후 무작위 순서로 표시됩니다.

) : (

* 후원 코드는 정확히 입력해야 합니다.

)}
{loading ? ( ) : displayList.length === 0 ? (
채널이 없습니다.
) : ( displayList.map(({ row: ch, isRecent }) => (
{isRecent && ( )}
)) )}
{requireChannel ? (
후원 채널 선택이 필수인 상품입니다.
) : (
)}
); }